package rbac

import (
	"context"
	"fmt"
	"testing"
	"time"

	"github.com/go-jose/go-jose/v3/jwt"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/sync/singleflight"
	"k8s.io/apiserver/pkg/endpoints/request"

	"github.com/grafana/authlib/authn"
	authzv1 "github.com/grafana/authlib/authz/proto/v1"
	"github.com/grafana/authlib/cache"
	"github.com/grafana/authlib/types"

	"github.com/grafana/grafana/pkg/apimachinery/utils"
	"github.com/grafana/grafana/pkg/infra/log"
	"github.com/grafana/grafana/pkg/infra/tracing"
	"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
	"github.com/grafana/grafana/pkg/services/accesscontrol"
	"github.com/grafana/grafana/pkg/services/authz/rbac/store"
)

func TestService_checkPermission(t *testing.T) {
	type testCase struct {
		name        string
		permissions []accesscontrol.Permission
		check       CheckRequest
		folders     []store.Folder
		expected    bool
	}

	testCases := []testCase{
		{
			name: "should return true if user has permission",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:read",
					Scope:      "dashboards:uid:some_dashboard",
					Kind:       "dashboards",
					Attribute:  "uid",
					Identifier: "some_dashboard",
				},
			},
			check: CheckRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Name:     "some_dashboard",
			},
			expected: true,
		},
		{
			name: "should return false if user has permission on a different resource",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:read",
					Scope:      "dashboards:uid:another_dashboard",
					Kind:       "dashboards",
					Attribute:  "uid",
					Identifier: "another_dashboard",
				},
			},
			check: CheckRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Name:     "some_dashboard",
			},
			expected: false,
		},
		{
			name: "should return true if user has wildcard permission on identifier",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:read",
					Scope:      "dashboards:uid:*",
					Kind:       "dashboards",
					Attribute:  "uid",
					Identifier: "*",
				},
			},
			check: CheckRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Name:     "some_dashboard",
			},
			expected: true,
		},
		{
			name: "should return true if user has wildcard permission on attribute",
			permissions: []accesscontrol.Permission{
				{
					Action:    "dashboards:read",
					Scope:     "dashboards:*",
					Kind:      "dashboards",
					Attribute: "*",
				},
			},
			check: CheckRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Name:     "some_dashboard",
			},
			expected: true,
		},
		{
			name: "should return true if user has wildcard permission on kind",
			permissions: []accesscontrol.Permission{
				{
					Action: "dashboards:read",
					Scope:  "*",
					Kind:   "*",
				},
			},
			check: CheckRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Name:     "some_dashboard",
			},
			expected: true,
		},
		{
			name: "should check general folder scope for root level resource creation",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:create",
					Scope:      "folders:uid:general",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "general",
				},
			},
			check: CheckRequest{
				Action:   "dashboards:create",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Verb:     utils.VerbCreate,
			},
			expected: true,
		},
		{
			name: "should fail if user doesn't have general folder scope for root level resource creation",
			permissions: []accesscontrol.Permission{
				{
					Action: "dashboards:create",
				},
			},
			check: CheckRequest{
				Action:   "dashboards:create",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Verb:     utils.VerbCreate,
			},
			expected: false,
		},
		{
			name:        "should return false if user has no permissions on resource",
			permissions: []accesscontrol.Permission{},
			check: CheckRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Name:     "some_dashboard",
			},
			expected: false,
		},
		{
			name: "should return true if user has permissions on folder",
			permissions: []accesscontrol.Permission{
				{
					Scope:      "folders:uid:parent",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "parent",
				},
			},
			folders: []store.Folder{
				{UID: "parent"},
				{UID: "child", ParentUID: strPtr("parent")},
			},
			check: CheckRequest{
				Action:       "dashboards:read",
				Group:        "dashboard.grafana.app",
				Resource:     "dashboards",
				Name:         "some_dashboard",
				ParentFolder: "child",
			},
			expected: true,
		},
		{
			name: "should allow creating a nested resource",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:create",
					Scope:      "folders:uid:parent",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "parent",
				},
			},
			folders: []store.Folder{{UID: "parent"}},
			check: CheckRequest{
				Action:       "dashboards:create",
				Group:        "dashboard.grafana.app",
				Resource:     "dashboards",
				Name:         "",
				ParentFolder: "parent",
				Verb:         utils.VerbCreate,
			},
			expected: true,
		},
		{
			name: "should deny creating a nested resource",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:create",
					Scope:      "folders:uid:parent",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "parent",
				},
			},
			folders: []store.Folder{{UID: "parent"}},
			check: CheckRequest{
				Action:       "dashboards:create",
				Group:        "dashboard.grafana.app",
				Resource:     "dashboards",
				Name:         "",
				ParentFolder: "other_parent",
				Verb:         utils.VerbCreate,
			},
			expected: false,
		},
		{
			name: "should allow if it's an any check",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:read",
					Scope:      "folders:uid:parent",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "parent",
				},
			},
			folders: []store.Folder{{UID: "parent"}},
			check: CheckRequest{
				Action:       "dashboards:read",
				Group:        "dashboard.grafana.app",
				Resource:     "dashboards",
				Name:         "",
				ParentFolder: "",
				Verb:         utils.VerbList,
			},
			expected: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			s := setupService()

			s.folderCache.Set(context.Background(), folderCacheKey("default"), newFolderTree(tc.folders))
			tc.check.Namespace = types.NamespaceInfo{Value: "default", OrgID: 1}
			got, err := s.checkPermission(context.Background(), getScopeMap(tc.permissions), &tc.check)
			require.NoError(t, err)
			assert.Equal(t, tc.expected, got)
		})
	}
}

func TestService_getUserTeams(t *testing.T) {
	type testCase struct {
		name          string
		teams         []int64
		cacheHit      bool
		expectedTeams []int64
		expectedError bool
	}

	testCases := []testCase{
		{
			name:          "should return teams from cache if available",
			teams:         []int64{1, 2},
			cacheHit:      true,
			expectedTeams: []int64{1, 2},
			expectedError: false,
		},
		{
			name:          "should return teams from identity store if not in cache",
			teams:         []int64{3, 4},
			cacheHit:      false,
			expectedTeams: []int64{3, 4},
			expectedError: false,
		},
		{
			name:          "should return error if identity store fails",
			teams:         []int64{},
			cacheHit:      false,
			expectedTeams: nil,
			expectedError: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			ctx := context.Background()
			s := setupService()
			ns := types.NamespaceInfo{Value: "stacks-12", OrgID: 1, StackID: 12}

			userIdentifiers := &store.UserIdentifiers{UID: "test-uid"}
			identityStore := &fakeIdentityStore{teams: tc.teams, err: tc.expectedError, disableNsCheck: true}
			s.identityStore = identityStore

			if tc.cacheHit {
				s.teamCache.Set(ctx, userTeamCacheKey(ns.Value, userIdentifiers.UID), tc.expectedTeams)
			}

			teams, err := s.getUserTeams(ctx, ns, userIdentifiers)
			if tc.expectedError {
				require.Error(t, err)
				return
			}

			require.NoError(t, err)
			require.ElementsMatch(t, tc.expectedTeams, teams)
			if tc.cacheHit {
				require.Zero(t, identityStore.calls)
			} else {
				require.Equal(t, 1, identityStore.calls)
			}
		})
	}
}

func TestService_getUserBasicRole(t *testing.T) {
	type testCase struct {
		name          string
		basicRole     store.BasicRole
		cacheHit      bool
		expectedRole  store.BasicRole
		expectedError bool
	}

	testCases := []testCase{
		{
			name: "should return basic role from cache if available",
			basicRole: store.BasicRole{
				Role:    "viewer",
				IsAdmin: false,
			},
			cacheHit: true,
			expectedRole: store.BasicRole{
				Role:    "viewer",
				IsAdmin: false,
			},
			expectedError: false,
		},
		{
			name: "should return basic role from store if not in cache",
			basicRole: store.BasicRole{
				Role:    "editor",
				IsAdmin: false,
			},
			cacheHit: false,
			expectedRole: store.BasicRole{
				Role:    "editor",
				IsAdmin: false,
			},
			expectedError: false,
		},
		{
			name:          "should return error if store fails",
			basicRole:     store.BasicRole{},
			cacheHit:      false,
			expectedRole:  store.BasicRole{},
			expectedError: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			ctx := context.Background()
			s := setupService()
			ns := types.NamespaceInfo{Value: "stacks-12", OrgID: 1, StackID: 12}

			userIdentifiers := &store.UserIdentifiers{UID: "test-uid", ID: 1}
			store := &fakeStore{basicRole: &tc.basicRole, err: tc.expectedError, disableNsCheck: true}
			s.store = store
			s.permissionStore = store

			if tc.cacheHit {
				s.basicRoleCache.Set(ctx, userBasicRoleCacheKey(ns.Value, userIdentifiers.UID), tc.expectedRole)
			}

			role, err := s.getUserBasicRole(ctx, ns, userIdentifiers)
			if tc.expectedError {
				require.Error(t, err)
				return
			}

			require.NoError(t, err)
			require.Equal(t, tc.expectedRole, role)
			if tc.cacheHit {
				require.Zero(t, store.calls)
			} else {
				require.Equal(t, 1, store.calls)
			}
		})
	}
}

func TestService_getUserPermissions(t *testing.T) {
	type testCase struct {
		name          string
		permissions   []accesscontrol.Permission
		cacheHit      bool
		expectedPerms map[string]bool
	}

	testCases := []testCase{
		{
			name: "should return permissions from store if not in cache",
			permissions: []accesscontrol.Permission{
				{Action: "dashboards:read", Scope: "dashboards:uid:some_dashboard"},
			},
			cacheHit:      false,
			expectedPerms: map[string]bool{"dashboards:uid:some_dashboard": true},
		},
		{
			name:          "should return error if store fails",
			permissions:   nil,
			cacheHit:      false,
			expectedPerms: map[string]bool{},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			ctx := context.Background()
			s := setupService()
			ns := types.NamespaceInfo{Value: "stacks-12", OrgID: 1, StackID: 12}

			userID := &store.UserIdentifiers{UID: "test-uid", ID: 112}
			action := "dashboards:read"

			if tc.cacheHit {
				s.permCache.Set(ctx, userPermCacheKey(ns.Value, userID.UID, action), tc.expectedPerms)
			}

			store := &fakeStore{
				userID:          userID,
				basicRole:       &store.BasicRole{Role: "viewer", IsAdmin: false},
				userPermissions: tc.permissions,
				disableNsCheck:  true,
			}
			s.store = store
			s.permissionStore = store
			s.identityStore = &fakeIdentityStore{teams: []int64{1, 2}, disableNsCheck: true}

			perms, err := s.getIdentityPermissions(ctx, ns, types.TypeUser, userID.UID, action)
			require.NoError(t, err)
			require.Len(t, perms, len(tc.expectedPerms))
			for _, perm := range tc.permissions {
				_, ok := tc.expectedPerms[perm.Scope]
				require.True(t, ok)
			}
			if tc.cacheHit {
				require.Equal(t, 1, store.calls) // only get user id
			} else {
				require.Equal(t, 3, store.calls) // get user id, basic role, and permissions
			}
		})
	}
}

func TestService_listPermission(t *testing.T) {
	type testCase struct {
		name            string
		permissions     []accesscontrol.Permission
		folders         []store.Folder
		list            ListRequest
		expectedItems   []string
		expectedFolders []string
		expectedAll     bool
	}

	testCases := []testCase{
		{
			name: "should return wildcard if user has a wildcard permission",
			permissions: []accesscontrol.Permission{
				{
					Action: "dashboards:read",
					Scope:  "*",
					Kind:   "*",
				},
			},
			list: ListRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
			},
			expectedAll: true,
		},
		{
			name: "should return dashboards and folders that user has direct access to",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:read",
					Scope:      "dashboards:uid:some_dashboard",
					Kind:       "dashboards",
					Attribute:  "uid",
					Identifier: "some_dashboard",
				},
				{
					Action:     "dashboards:read",
					Scope:      "folders:uid:some_folder_1",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "some_folder_1",
				},
				{
					Action:     "dashboards:read",
					Scope:      "folders:uid:some_folder_2",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "some_folder_2",
				},
			},
			folders: []store.Folder{
				{UID: "some_folder_1"},
				{UID: "some_folder_2"},
			},
			list: ListRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
			},
			expectedItems:   []string{"some_dashboard"},
			expectedFolders: []string{"some_folder_1", "some_folder_2"},
		},
		{
			name: "should return folders that user has inherited access to",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:read",
					Scope:      "folders:uid:some_folder_parent",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "some_folder_1",
				},
			},
			folders: []store.Folder{
				{UID: "some_folder_parent"},
				{UID: "some_folder_child", ParentUID: strPtr("some_folder_parent")},
				{UID: "some_folder_subchild1", ParentUID: strPtr("some_folder_child")},
				{UID: "some_folder_subchild2", ParentUID: strPtr("some_folder_child")},
				{UID: "some_folder_subsubchild", ParentUID: strPtr("some_folder_subchild2")},
				{UID: "some_folder_1", ParentUID: strPtr("some_other_folder")},
			},
			list: ListRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
			},
			expectedFolders: []string{"some_folder_parent", "some_folder_child", "some_folder_subchild1", "some_folder_subchild2", "some_folder_subsubchild"},
		},
		{
			name: "should return folders that user has inherited access to as well as dashboards that user has direct access to",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:read",
					Scope:      "dashboards:uid:some_dashboard",
					Kind:       "dashboards",
					Attribute:  "uid",
					Identifier: "some_dashboard",
				},
				{
					Action:     "dashboards:read",
					Scope:      "folders:uid:some_folder_parent",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "some_folder_parent",
				},
			},
			folders: []store.Folder{
				{UID: "some_folder_parent"},
				{UID: "some_folder_child", ParentUID: strPtr("some_folder_parent")},
			},
			list: ListRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
			},
			expectedItems:   []string{"some_dashboard"},
			expectedFolders: []string{"some_folder_parent", "some_folder_child"},
		},
		{
			name: "should deduplicate folders that user has inherited as well as direct access to",
			permissions: []accesscontrol.Permission{
				{
					Action:     "dashboards:read",
					Scope:      "folders:uid:some_folder_child",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "some_folder_child",
				},
				{
					Action:     "dashboards:read",
					Scope:      "folders:uid:some_folder_parent",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "some_folder_parent",
				},
			},
			folders: []store.Folder{
				{UID: "some_folder_parent"},
				{UID: "some_folder_child", ParentUID: strPtr("some_folder_parent")},
				{UID: "some_folder_subchild", ParentUID: strPtr("some_folder_child")},
				{UID: "some_folder_child2", ParentUID: strPtr("some_folder_parent")},
			},
			list: ListRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
			},
			expectedFolders: []string{"some_folder_parent", "some_folder_child", "some_folder_child2", "some_folder_subchild"},
		},
		{
			name:        "return no dashboards and folders if the user doesn't have access to any resources",
			permissions: []accesscontrol.Permission{},

			folders: []store.Folder{
				{UID: "some_folder_1"},
			},
			list: ListRequest{
				Action:   "dashboards:read",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
			},
		},
		{
			name: "should collect folder permissions into items",
			permissions: []accesscontrol.Permission{
				{
					Action:     "folders:read",
					Scope:      "folders:uid:some_folder_parent",
					Kind:       "folders",
					Attribute:  "uid",
					Identifier: "some_folder_parent",
				},
			},
			folders: []store.Folder{
				{UID: "some_folder_parent"},
				{UID: "some_folder_child", ParentUID: strPtr("some_folder_parent")},
			},
			list: ListRequest{
				Action:   "folders:read",
				Group:    "folder.grafana.app",
				Resource: "folders",
			},
			expectedItems: []string{"some_folder_parent", "some_folder_child"},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			s := setupService()
			if tc.folders != nil {
				s.folderCache.Set(context.Background(), folderCacheKey("default"), newFolderTree(tc.folders))
			}

			tc.list.Namespace = types.NamespaceInfo{Value: "default", OrgID: 1}
			got, err := s.listPermission(context.Background(), getScopeMap(tc.permissions), &tc.list)
			require.NoError(t, err)
			assert.Equal(t, tc.expectedAll, got.All)
			assert.ElementsMatch(t, tc.expectedItems, got.Items)
			assert.ElementsMatch(t, tc.expectedFolders, got.Folders)
		})
	}
}

func TestService_Check(t *testing.T) {
	callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
		Claims: jwt.Claims{
			Subject:  types.NewTypeID(types.TypeAccessPolicy, "some-service"),
			Audience: []string{"authzservice"},
		},
		Rest: authn.AccessTokenClaims{Namespace: "org-12"},
	})

	type testCase struct {
		name        string
		req         *authzv1.CheckRequest
		permissions []accesscontrol.Permission
		expected    bool
		expectErr   bool
	}

	t.Run("Require auth info", func(t *testing.T) {
		s := setupService()
		ctx := context.Background()
		_, err := s.Check(ctx, &authzv1.CheckRequest{
			Namespace: "org-12",
			Subject:   "user:test-uid",
			Group:     "dashboard.grafana.app",
			Resource:  "dashboards",
			Verb:      "get",
			Name:      "dash1",
		})
		require.Error(t, err)
		require.Contains(t, err.Error(), "could not get auth info")
	})

	testCases := []testCase{
		{
			name: "should error if no namespace is provided",
			req: &authzv1.CheckRequest{
				Namespace: "",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			expectErr: true,
		},
		{
			name: "should error if caller namespace does not match request namespace",
			req: &authzv1.CheckRequest{
				Namespace: "org-13",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			expectErr: true,
		},
		{
			name: "should error if no subject is provided",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			expectErr: true,
		},
		{
			name: "should error if an unsupported subject type is provided",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "api-key:12",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			expectErr: true,
		},
		{
			name: "should error if an invalid subject is provided",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "invalid:12",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			expectErr: true,
		},
		{
			name: "should error if an unknown group is provided",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "user:test-uid",
				Group:     "unknown.grafana.app",
				Resource:  "unknown",
				Verb:      "get",
				Name:      "u1",
			},
			expectErr: true,
		},
		{
			name: "should error if an unknown verb is provided",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "unknown",
				Name:      "u1",
			},
			expectErr: true,
		},
	}
	t.Run("Request validation", func(t *testing.T) {
		for _, tc := range testCases {
			t.Run(tc.name, func(t *testing.T) {
				s := setupService()
				ctx := types.WithAuthInfo(context.Background(), callingService)
				userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
				store := &fakeStore{
					userID:          userID,
					userPermissions: tc.permissions,
				}
				s.store = store
				s.permissionStore = store
				s.identityStore = &fakeIdentityStore{}

				_, err := s.Check(ctx, tc.req)
				require.Error(t, err)
			})
		}
	})

	testCases = []testCase{
		{
			name: "should allow user with permission",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash1"}},
			expected:    true,
		},
		{
			name: "should deny user without permission",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
			expected:    false,
		},
	}
	t.Run("User permission check", func(t *testing.T) {
		for _, tc := range testCases {
			t.Run(tc.name, func(t *testing.T) {
				s := setupService()
				ctx := types.WithAuthInfo(context.Background(), callingService)
				userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
				store := &fakeStore{
					userID:          userID,
					userPermissions: tc.permissions,
				}
				s.store = store
				s.permissionStore = store
				s.identityStore = &fakeIdentityStore{}

				resp, err := s.Check(ctx, tc.req)
				require.NoError(t, err)
				assert.Equal(t, tc.expected, resp.Allowed)

				// Check cache
				id, ok := s.idCache.Get(ctx, userIdentifierCacheKey("org-12", "test-uid"))
				require.True(t, ok)
				require.Equal(t, id.UID, "test-uid")
				perms, ok := s.permCache.Get(ctx, userPermCacheKey("org-12", "test-uid", "dashboards:read"))
				require.True(t, ok)
				require.Len(t, perms, 1)
			})
		}
	})

	testCases = []testCase{
		{
			name: "should allow anonymous with permission",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "anonymous:0",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash1"}},
			expected:    true,
		},
		{
			name: "should deny anonymous without permission",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "anonymous:0",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
			expected:    false,
		},
	}
	t.Run("Anonymous permission check", func(t *testing.T) {
		for _, tc := range testCases {
			t.Run(tc.name, func(t *testing.T) {
				s := setupService()
				ctx := types.WithAuthInfo(context.Background(), callingService)
				store := &fakeStore{userPermissions: tc.permissions}
				s.store = store
				s.permissionStore = store
				s.identityStore = &fakeIdentityStore{}

				resp, err := s.Check(ctx, tc.req)
				require.NoError(t, err)
				assert.Equal(t, tc.expected, resp.Allowed)

				// Check cache
				perms, ok := s.permCache.Get(ctx, anonymousPermCacheKey("org-12", "dashboards:read"))
				require.True(t, ok)
				require.Len(t, perms, 1)
			})
		}
	})

	testCases = []testCase{
		{
			name: "should allow rendering with permission",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "render:0",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			expected: true,
		},
		{
			name: "should deny rendering access to another app resources",
			req: &authzv1.CheckRequest{
				Namespace: "org-12",
				Subject:   "render:0",
				Group:     "another.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
				Name:      "dash1",
			},
			expected:  false,
			expectErr: true,
		},
	}
	t.Run("Rendering permission check", func(t *testing.T) {
		for _, tc := range testCases {
			t.Run(tc.name, func(t *testing.T) {
				s := setupService()
				ctx := types.WithAuthInfo(context.Background(), callingService)

				resp, err := s.Check(ctx, tc.req)
				if tc.expectErr {
					require.Error(t, err)
					return
				}
				require.NoError(t, err)
				assert.Equal(t, tc.expected, resp.Allowed)
			})
		}
	})
}

func TestService_CacheCheck(t *testing.T) {
	callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
		Claims: jwt.Claims{
			Subject:  types.NewTypeID(types.TypeAccessPolicy, "some-service"),
			Audience: []string{"authzservice"},
		},
		Rest: authn.AccessTokenClaims{Namespace: "org-12"},
	})

	ctx := types.WithAuthInfo(context.Background(), callingService)
	userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}

	t.Run("Allow based on cached permissions", func(t *testing.T) {
		s := setupService()

		s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)
		s.permCache.Set(ctx, userPermCacheKey("org-12", "test-uid", "dashboards:read"), map[string]bool{"dashboards:uid:dash1": true})

		resp, err := s.Check(ctx, &authzv1.CheckRequest{
			Namespace: "org-12",
			Subject:   "user:test-uid",
			Group:     "dashboard.grafana.app",
			Resource:  "dashboards",
			Verb:      "get",
			Name:      "dash1",
		})
		require.NoError(t, err)
		assert.True(t, resp.Allowed)
	})
	t.Run("Fallback to the database on cache miss", func(t *testing.T) {
		s := setupService()

		// Populate database permission but not the cache
		store := &fakeStore{
			userID:          userID,
			userPermissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
		}

		s.store = store
		s.permissionStore = store

		s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)

		resp, err := s.Check(ctx, &authzv1.CheckRequest{
			Namespace: "org-12",
			Subject:   "user:test-uid",
			Group:     "dashboard.grafana.app",
			Resource:  "dashboards",
			Verb:      "get",
			Name:      "dash2",
		})
		require.NoError(t, err)
		assert.True(t, resp.Allowed)
	})
	t.Run("Fallback to the database on outdated cache", func(t *testing.T) {
		s := setupService()

		store := &fakeStore{
			userID:          userID,
			userPermissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
		}

		s.store = store
		s.permissionStore = store

		s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)
		// The cache does not have the permission for dash2 (outdated)
		s.permCache.Set(ctx, userPermCacheKey("org-12", "test-uid", "dashboards:read"), map[string]bool{"dashboards:uid:dash1": true})

		resp, err := s.Check(ctx, &authzv1.CheckRequest{
			Namespace: "org-12",
			Subject:   "user:test-uid",
			Group:     "dashboard.grafana.app",
			Resource:  "dashboards",
			Verb:      "get",
			Name:      "dash2",
		})
		require.NoError(t, err)
		assert.True(t, resp.Allowed)
	})
	t.Run("Should deny on explicit cache deny entry", func(t *testing.T) {
		s := setupService()

		s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)

		// Explicitly deny access to the dashboard
		s.permDenialCache.Set(ctx, userPermDenialCacheKey("org-12", "test-uid", "dashboards:read", "dash1", "fold1"), true)

		// Allow access to the dashboard to prove this is not checked
		s.permCache.Set(ctx, userPermCacheKey("org-12", "test-uid", "dashboards:read"), map[string]bool{"dashboards:uid:dash1": false})

		resp, err := s.Check(ctx, &authzv1.CheckRequest{
			Namespace: "org-12",
			Subject:   "user:test-uid",
			Group:     "dashboard.grafana.app",
			Resource:  "dashboards",
			Verb:      "get",
			Name:      "dash1",
			Folder:    "fold1",
		})
		require.NoError(t, err)
		assert.False(t, resp.Allowed)
	})
}

func TestService_List(t *testing.T) {
	callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
		Claims: jwt.Claims{
			Subject:  types.NewTypeID(types.TypeAccessPolicy, "some-service"),
			Audience: []string{"authzservice"},
		},
		Rest: authn.AccessTokenClaims{Namespace: "org-12"},
	})

	type testCase struct {
		name        string
		req         *authzv1.ListRequest
		permissions []accesscontrol.Permission
		expected    *authzv1.ListResponse
		expectErr   bool
	}

	t.Run("Require auth info", func(t *testing.T) {
		s := setupService()
		ctx := context.Background()
		_, err := s.List(ctx, &authzv1.ListRequest{
			Namespace: "org-12",
			Subject:   "user:test-uid",
			Group:     "dashboard.grafana.app",
			Resource:  "dashboards",
			Verb:      "get",
		})
		require.Error(t, err)
		require.Contains(t, err.Error(), "could not get auth info")
	})

	testCases := []testCase{
		{
			name: "should error if no namespace is provided",
			req: &authzv1.ListRequest{
				Namespace: "",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			expectErr: true,
		},
		{
			name: "should error if caller namespace does not match request namespace",
			req: &authzv1.ListRequest{
				Namespace: "org-13",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			expectErr: true,
		},
		{
			name: "should error if no subject is provided",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			expectErr: true,
		},
		{
			name: "should error if an unsupported subject type is provided",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "api-key:12",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			expectErr: true,
		},
		{
			name: "should error if an invalid subject is provided",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "invalid:12",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			expectErr: true,
		},
		{
			name: "should error if an unknown group is provided",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "user:test-uid",
				Group:     "unknown.grafana.app",
				Resource:  "unknown",
				Verb:      "get",
			},
			expectErr: true,
		},
		{
			name: "should error if an unknown verb is provided",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "unknown",
			},
			expectErr: true,
		},
	}
	t.Run("Request validation", func(t *testing.T) {
		for _, tc := range testCases {
			t.Run(tc.name, func(t *testing.T) {
				s := setupService()
				ctx := types.WithAuthInfo(context.Background(), callingService)
				userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
				store := &fakeStore{
					userID:          userID,
					userPermissions: tc.permissions,
				}
				s.store = store
				s.permissionStore = store
				s.identityStore = &fakeIdentityStore{}

				_, err := s.List(ctx, tc.req)
				require.Error(t, err)
			})
		}
	})

	testCases = []testCase{
		{
			name: "should list permissions for user with permission",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			permissions: []accesscontrol.Permission{
				{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
				{Action: "dashboards:read", Scope: "dashboards:uid:dash2"},
				{Action: "dashboards:read", Scope: "folders:uid:fold1"},
			},
			expected: &authzv1.ListResponse{
				Items:   []string{"dash1", "dash2"},
				Folders: []string{"fold1"},
			},
		},
		{
			name: "should return empty list for user without permission",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "user:test-uid",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			permissions: []accesscontrol.Permission{},
			expected:    &authzv1.ListResponse{},
		},
	}
	t.Run("User permission list", func(t *testing.T) {
		for _, tc := range testCases {
			t.Run(tc.name, func(t *testing.T) {
				s := setupService()
				ctx := types.WithAuthInfo(context.Background(), callingService)
				userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
				store := &fakeStore{
					userID:          userID,
					userPermissions: tc.permissions,
				}
				s.store = store
				s.permissionStore = store
				s.identityStore = &fakeIdentityStore{}

				resp, err := s.List(ctx, tc.req)
				require.NoError(t, err)
				require.ElementsMatch(t, resp.Items, tc.expected.Items)
				require.ElementsMatch(t, resp.Folders, tc.expected.Folders)

				// Check cache
				id, ok := s.idCache.Get(ctx, userIdentifierCacheKey("org-12", "test-uid"))
				require.True(t, ok)
				require.Equal(t, id.UID, "test-uid")
				perms, ok := s.permCache.Get(ctx, userPermCacheKey("org-12", "test-uid", "dashboards:read"))
				require.True(t, ok)
				require.Len(t, perms, len(tc.expected.Items)+len(tc.expected.Folders))
			})
		}
	})

	testCases = []testCase{
		{
			name: "should list permissions for anonymous with permission",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "anonymous:0",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			permissions: []accesscontrol.Permission{
				{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
				{Action: "dashboards:read", Scope: "dashboards:uid:dash2"},
				{Action: "dashboards:read", Scope: "folders:uid:fold1"},
			},
			expected: &authzv1.ListResponse{
				Items:   []string{"dash1", "dash2"},
				Folders: []string{"fold1"},
			},
		},
		{
			name: "should return empty list for anonymous without permission",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "anonymous:0",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			permissions: []accesscontrol.Permission{},
			expected:    &authzv1.ListResponse{},
		},
	}
	t.Run("Anonymous permission list", func(t *testing.T) {
		for _, tc := range testCases {
			t.Run(tc.name, func(t *testing.T) {
				s := setupService()
				ctx := types.WithAuthInfo(context.Background(), callingService)
				store := &fakeStore{userPermissions: tc.permissions}
				s.store = store
				s.permissionStore = store
				s.identityStore = &fakeIdentityStore{}

				resp, err := s.List(ctx, tc.req)
				require.NoError(t, err)
				require.ElementsMatch(t, resp.Items, tc.expected.Items)
				require.ElementsMatch(t, resp.Folders, tc.expected.Folders)

				// Check cache
				perms, ok := s.permCache.Get(ctx, anonymousPermCacheKey("org-12", "dashboards:read"))
				require.True(t, ok)
				require.Len(t, perms, len(tc.expected.Items)+len(tc.expected.Folders))
			})
		}
	})

	testCases = []testCase{
		{
			name: "should list permissions for rendering",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "render:0",
				Group:     "dashboard.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			expected: &authzv1.ListResponse{
				All: true,
			},
		},
		{
			name: "should deny rendering access to another app resources",
			req: &authzv1.ListRequest{
				Namespace: "org-12",
				Subject:   "render:0",
				Group:     "another.grafana.app",
				Resource:  "dashboards",
				Verb:      "get",
			},
			expectErr: true,
		},
	}
	t.Run("Rendering permission list", func(t *testing.T) {
		for _, tc := range testCases {
			t.Run(tc.name, func(t *testing.T) {
				s := setupService()
				ctx := types.WithAuthInfo(context.Background(), callingService)

				resp, err := s.List(ctx, tc.req)
				if tc.expectErr {
					require.Error(t, err)
					return
				}
				require.NoError(t, err)
				assert.Equal(t, tc.expected.All, resp.All)
			})
		}
	})
}

func TestService_getAnonymousPermissions(t *testing.T) {
	type testCase struct {
		name          string
		permissions   []accesscontrol.Permission
		action        string
		expectedPerms map[string]bool
		expectedError bool
		anonRole      string
	}

	testCases := []testCase{
		{
			name: "should return permissions from store if not in cache",
			permissions: []accesscontrol.Permission{
				{Action: "dashboards:read", Scope: "dashboards:uid:some_dashboard"},
			},
			action:        "dashboards:read",
			expectedPerms: map[string]bool{"dashboards:uid:some_dashboard": true},
			expectedError: false,
			anonRole:      "Viewer",
		},
		{
			name:          "should return error if store fails",
			permissions:   nil,
			action:        "dashboards:read",
			expectedPerms: nil,
			expectedError: true,
			anonRole:      "Viewer",
		},
		{
			name: "should handle wildcard permissions",
			permissions: []accesscontrol.Permission{
				{Action: "dashboards:read", Scope: "*", Kind: "*"},
			},
			action:        "dashboards:read",
			expectedPerms: map[string]bool{"*": true},
			expectedError: false,
			anonRole:      "Viewer",
		},
		{
			name: "should use custom anonymous role when specified",
			permissions: []accesscontrol.Permission{
				{Action: "dashboards:read", Scope: "dashboards:uid:some_dashboard"},
			},
			action:        "dashboards:read",
			expectedPerms: map[string]bool{"dashboards:uid:some_dashboard": true},
			expectedError: false,
			anonRole:      "Editor",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			ctx := context.Background()
			s := setupService()
			if tc.anonRole != "" {
				s.settings.AnonOrgRole = tc.anonRole
			}
			ns := types.NamespaceInfo{Value: "stacks-12", OrgID: 1, StackID: 12}
			store := &fakeStore{
				userPermissions: tc.permissions,
				err:             tc.expectedError,
				disableNsCheck:  true,
			}
			s.store = store
			s.permissionStore = store

			perms, err := s.getAnonymousPermissions(ctx, ns, tc.action, []string{})
			if tc.expectedError {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)
			require.Equal(t, tc.expectedPerms, perms)

			// cache should then be set
			cached, ok := s.permCache.Get(ctx, anonymousPermCacheKey(ns.Value, tc.action))
			require.True(t, ok)
			require.Equal(t, tc.expectedPerms, cached)
		})
	}
}

func TestService_CacheList(t *testing.T) {
	callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
		Claims: jwt.Claims{
			Subject:  types.NewTypeID(types.TypeAccessPolicy, "some-service"),
			Audience: []string{"authzservice"},
		},
		Rest: authn.AccessTokenClaims{Namespace: "org-12"},
	})

	t.Run("List based on cached permissions", func(t *testing.T) {
		s := setupService()
		ctx := types.WithAuthInfo(context.Background(), callingService)
		userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
		s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)
		s.permCache.Set(ctx,
			userPermCacheKey("org-12", "test-uid", "dashboards:read"),
			map[string]bool{"dashboards:uid:dash1": true, "dashboards:uid:dash2": true, "folders:uid:fold1": true},
		)
		s.identityStore = &fakeIdentityStore{}

		resp, err := s.List(ctx, &authzv1.ListRequest{
			Namespace: "org-12",
			Subject:   "user:test-uid",
			Group:     "dashboard.grafana.app",
			Resource:  "dashboards",
			Verb:      "list",
		})

		require.NoError(t, err)
		require.ElementsMatch(t, resp.Items, []string{"dash1", "dash2"})
		require.ElementsMatch(t, resp.Folders, []string{"fold1"})
	})
}

func setupService() *Service {
	cache := cache.NewLocalCache(cache.Config{Expiry: 5 * time.Minute, CleanupInterval: 5 * time.Minute})
	logger := log.New("authz-rbac-service")
	fStore := &fakeStore{}
	return &Service{
		logger:          logger,
		mapper:          newMapper(),
		tracer:          tracing.NewNoopTracerService(),
		metrics:         newMetrics(nil),
		idCache:         newCacheWrap[store.UserIdentifiers](cache, logger, longCacheTTL),
		permCache:       newCacheWrap[map[string]bool](cache, logger, shortCacheTTL),
		permDenialCache: newCacheWrap[bool](cache, logger, shortCacheTTL),
		teamCache:       newCacheWrap[[]int64](cache, logger, shortCacheTTL),
		basicRoleCache:  newCacheWrap[store.BasicRole](cache, logger, longCacheTTL),
		folderCache:     newCacheWrap[folderTree](cache, logger, shortCacheTTL),
		settings:        Settings{AnonOrgRole: "Viewer"},
		store:           fStore,
		permissionStore: fStore,
		folderStore:     fStore,
		identityStore:   &fakeIdentityStore{},
		sf:              new(singleflight.Group),
	}
}

func strPtr(s string) *string {
	return &s
}

type fakeStore struct {
	store.Store
	// The namespace has to be set in the handlers for the correct organization to be picked up.
	disableNsCheck  bool
	folders         []store.Folder
	basicRole       *store.BasicRole
	userID          *store.UserIdentifiers
	userPermissions []accesscontrol.Permission
	err             bool
	calls           int
}

func (f *fakeStore) GetBasicRoles(ctx context.Context, namespace types.NamespaceInfo, query store.BasicRoleQuery) (*store.BasicRole, error) {
	if ns, ok := request.NamespaceFrom(ctx); !f.disableNsCheck && (!ok || ns != namespace.Value) {
		return nil, fmt.Errorf("namespace mismatch")
	}
	f.calls++
	if f.err {
		return nil, fmt.Errorf("store error")
	}
	return f.basicRole, nil
}

func (f *fakeStore) GetUserIdentifiers(ctx context.Context, query store.UserIdentifierQuery) (*store.UserIdentifiers, error) {
	if _, ok := request.NamespaceFrom(ctx); !f.disableNsCheck && !ok {
		return nil, fmt.Errorf("namespace not found")
	}
	f.calls++
	if f.err {
		return nil, fmt.Errorf("store error")
	}
	return f.userID, nil
}

func (f *fakeStore) GetUserPermissions(ctx context.Context, namespace types.NamespaceInfo, query store.PermissionsQuery) ([]accesscontrol.Permission, error) {
	if ns, ok := request.NamespaceFrom(ctx); !f.disableNsCheck && (!ok || ns != namespace.Value) {
		return nil, fmt.Errorf("namespace mismatch")
	}
	f.calls++
	if f.err {
		return nil, fmt.Errorf("store error")
	}
	return f.userPermissions, nil
}

func (f *fakeStore) ListFolders(ctx context.Context, namespace types.NamespaceInfo) ([]store.Folder, error) {
	if ns, ok := request.NamespaceFrom(ctx); !f.disableNsCheck && (!ok || ns != namespace.Value) {
		return nil, fmt.Errorf("namespace mismatch")
	}
	f.calls++
	if f.err {
		return nil, fmt.Errorf("store error")
	}
	return f.folders, nil
}

type fakeIdentityStore struct {
	legacy.LegacyIdentityStore
	teams          []int64
	disableNsCheck bool
	err            bool
	calls          int
}

func (f *fakeIdentityStore) ListUserTeams(ctx context.Context, namespace types.NamespaceInfo, query legacy.ListUserTeamsQuery) (*legacy.ListUserTeamsResult, error) {
	if ns, ok := request.NamespaceFrom(ctx); !f.disableNsCheck && (!ok || ns != namespace.Value) {
		return nil, fmt.Errorf("namespace mismatch")
	}
	f.calls++
	if f.err {
		return nil, fmt.Errorf("identity store error")
	}
	items := make([]legacy.UserTeam, 0, len(f.teams))
	for _, teamID := range f.teams {
		items = append(items, legacy.UserTeam{ID: teamID})
	}
	return &legacy.ListUserTeamsResult{
		Items:    items,
		Continue: 0,
	}, nil
}
